/*
 * This file is part of aion-emu <aion-emu.com>.
 *
 *  aion-emu is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  aion-emu is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with aion-emu.  If not, see <http://www.gnu.org/licenses/>.
 */
package com.aionengine.gameserver.dataholders.loadingutils;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.filefilter.HiddenFileFilter;
import org.apache.commons.io.filefilter.IOFileFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.Attributes;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;
import org.xml.sax.helpers.DefaultHandler;

import javax.xml.namespace.QName;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import javax.xml.stream.*;
import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.Comment;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;

import java.io.*;
import java.util.Collection;
import java.util.Properties;

import static org.apache.commons.io.filefilter.FileFilterUtils.*;

/**
 * <p>
 * <code>XmlMerger</code> is a utility that writes XML document onto an other document with resolving all
 * <code>import</code> elements.
 * </p>
 * <p>
 * Schema:
 * <p/>
 * <pre>
 * &lt;xs:element name="import"&gt;
 * &lt;xs:annotation&gt;
 * &lt;xs:documentation&gt;&lt;![CDATA[
 *      Attributes:
 *          'file' :
 *              Required attribute.
 *              Specified path to imported file or directory.
 *          'skipRoot' :
 *              Optional attribute.
 *              Default value: 'false'.
 *              If enabled, then root tags of imported files are ignored.
 *          'recirsiveImport':
 *              Optional attribute.
 *              Default value: 'true'.
 *              If enabled and attribute 'file' points to the directory, then all xml files in that
 *              directory ( and deeper - recursively ) will be imported, otherwise only files inside
 *              that directory (without it subdirectories)
 *  ]]&gt;&lt;/xs:documentation&gt;
 * &lt;/xs:annotation&gt;
 * &lt;xs:complexType&gt;
 * &lt;xs:attribute type="xs:string" name="file" use="required"/&gt;
 * &lt;xs:attribute type="xs:boolean" name="skipRoot" use="optional" default="false"/&gt;
 * &lt;xs:attribute type="xs:boolean" name="recursiveImport" use="optional" default="true" /&gt;
 * &lt;/xs:complexType&gt;
 * &lt;/xs:element&gt;
 * </pre>
 * <p/>
 * </p>
 * <p/>
 * Created on: 23.07.2009 12:55:14
 *
 * @author Aquanox
 */
public class XmlMerger {

    private static final Logger logger = LoggerFactory.getLogger(XmlMerger.class);

    private final File baseDir;

    private final File sourceFile;
    private final File destFile;

    private final File metaDataFile;

    private XMLInputFactory inputFactory = XMLInputFactory.newInstance();
    private XMLOutputFactory outputFactory = XMLOutputFactory.newInstance();
    private XMLEventFactory eventFactory = XMLEventFactory.newInstance();

    /**
     * Create new instance of <tt>XmlMerger </tt>. Base directory is set to directory which contains source file.
     *
     * @param source Source file.
     * @param target Destination file.
     */
    public XmlMerger(File source, File target) {
        this(source, target, source.getParentFile());
    }

    /**
     * Create new instance of <tt>XmlMerger </tt>
     *
     * @param source  Source file.
     * @param target  Destination file.
     * @param baseDir Root directory.
     */
    public XmlMerger(File source, File target, File baseDir) {
        this.baseDir = baseDir;

        this.sourceFile = source;
        this.destFile = target;

        this.metaDataFile = new File(target.getParent(), target.getName() + ".properties");
    }

    /**
     * This method creates a result document if it is missing, or updates existing one if the source file has
     * modification.<br />
     * If there are no changes - nothing happens.
     *
     * @throws FileNotFoundException when source file doesn't exists.
     * @throws XMLStreamException    when XML processing error was occurred.
     */
    public void process() throws Exception {
        logger.debug("Processing " + sourceFile + " files into " + destFile);

        if (!sourceFile.exists())
            throw new FileNotFoundException("Source file " + sourceFile.getPath() + " not found.");

        boolean needUpdate = false;

        if (!destFile.exists()) {
            logger.debug("Dest file not found - creating new file");
            needUpdate = true;
        } else if (!metaDataFile.exists()) {
            logger.debug("Meta file not found - creating new file");
            needUpdate = true;
        } else {
            logger.debug("Dest file found - checking file modifications");
            needUpdate = checkFileModifications();
        }

        if (needUpdate) {
            logger.debug("Modifications found. Updating...");
            try {
                doUpdate();
            } catch (Exception e) {
                FileUtils.deleteQuietly(destFile);
                FileUtils.deleteQuietly(metaDataFile);
                throw e;
            }
        } else {
            logger.debug("Files are up-to-date");
        }
    }

    /**
     * Check for modifications of included files.
     *
     * @return <code>true</code> if at least one of included files has modifications.
     * @throws IOException                  IO Error.
     * @throws SAXException                 Document parsing error.
     * @throws ParserConfigurationException if a SAX parser cannot be created which satisfies the requested configuration.
     */
    private boolean checkFileModifications() throws Exception {
        long destFileTime = destFile.lastModified();

        if (sourceFile.lastModified() > destFileTime) {
            logger.debug("Source file was modified ");
            return true;
        }

        Properties metadata = restoreFileModifications(metaDataFile);

        if (metadata == null) // new file or smth else.
            return true;

        SAXParserFactory parserFactory = SAXParserFactory.newInstance();

        SAXParser parser = parserFactory.newSAXParser();

        TimeCheckerHandler handler = new TimeCheckerHandler(baseDir, metadata);

        parser.parse(sourceFile, handler);

        return handler.isModified();
    }

    /**
     * This method processes the source file, replacing all of the 'import' tags by the data from the relevant files.
     *
     * @throws XMLStreamException on event writing error.
     * @throws IOException        if the destination file exists but is a directory rather than a regular file, does not exist but cannot
     *                            be created, or cannot be opened for any other reason
     */
    private void doUpdate() throws XMLStreamException, IOException {
        XMLEventReader reader = null;
        XMLEventWriter writer = null;

        Properties metadata = new Properties();

        try {
            writer = outputFactory.createXMLEventWriter(new BufferedWriter(new FileWriter(destFile, false)));
            reader = inputFactory.createXMLEventReader(new FileReader(sourceFile));

            while (reader.hasNext()) {
                final XMLEvent xmlEvent = reader.nextEvent();

                if (xmlEvent.isStartElement() && isImportQName(xmlEvent.asStartElement().getName())) {
                    processImportElement(xmlEvent.asStartElement(), writer, metadata);
                    continue;
                }

                if (xmlEvent.isEndElement() && isImportQName(xmlEvent.asEndElement().getName()))
                    continue;

                if (xmlEvent instanceof Comment)// skip comments.
                    continue;

                if (xmlEvent.isCharacters())// skip whitespaces.
                    if (xmlEvent.asCharacters().isWhiteSpace() || xmlEvent.asCharacters().isIgnorableWhiteSpace())// skip
                        // whitespaces.
                        continue;

                writer.add(xmlEvent);

                if (xmlEvent.isStartDocument()) {
                    writer.add(eventFactory.createComment("\nThis file is machine-generated. DO NOT MODIFY IT!\n"));
                }
            }

            storeFileModifications(metadata, metaDataFile);
        } finally {
            if (writer != null)
                try {
                    writer.close();
                } catch (Exception ignored) {
                }
            if (reader != null)
                try {
                    reader.close();
                } catch (Exception ignored) {
                }
        }
    }

    private boolean isImportQName(QName name) {
        return "import".equals(name.getLocalPart());
    }

    private static final QName qNameFile = new QName("file");
    private static final QName qNameSkipRoot = new QName("skipRoot");

    /**
     * If this option is enabled you import the directory, and all its subdirectories. Default is 'true'.
     */
    private static final QName qNameRecursiveImport = new QName("recursiveImport");

    /**
     * This method processes the 'import' element, replacing it by the data from the relevant files.
     *
     * @throws XMLStreamException    on event writing error.
     * @throws FileNotFoundException of imported file was not found.
     */
    private void processImportElement(StartElement element, XMLEventWriter writer, Properties metadata)
            throws XMLStreamException, IOException {
        File file = new File(baseDir, getAttributeValue(element, qNameFile, null, "Attribute 'file' is missing or empty."));

        if (!file.exists())
            throw new FileNotFoundException("Missing file to import:" + file.getPath());

        boolean skipRoot = Boolean.valueOf(getAttributeValue(element, qNameSkipRoot, "false", null));
        boolean recImport = Boolean.valueOf(getAttributeValue(element, qNameRecursiveImport, "true", null));

        if (file.isFile()) {
            importFile(file, skipRoot, writer, metadata);
        } else {
            logger.debug("Processing dir " + file);

            Collection<File> files = listFiles(file, recImport);

            for (File childFile : files) {
                importFile(childFile, skipRoot, writer, metadata);
            }
        }
    }

    @SuppressWarnings("deprecation")
	private static Collection<File> listFiles(File root, boolean recursive) {
        IOFileFilter dirFilter = recursive ? makeSVNAware(HiddenFileFilter.VISIBLE) : null;

        return FileUtils.listFiles(
                root,
                andFileFilter(andFileFilter(notFileFilter(prefixFileFilter("new")), suffixFileFilter(".xml")),
                        HiddenFileFilter.VISIBLE), dirFilter);
    }

    /**
     * Extract an attribute value from a <code>StartElement </code> event.
     *
     * @param element        Event object.
     * @param name           Attribute QName
     * @param def            Default value.
     * @param onErrorMessage On error message.
     * @return attribute value
     * @throws XMLStreamException if attribute is missing and there is no default value set.
     */
    private String getAttributeValue(StartElement element, QName name, String def, String onErrorMessage)
            throws XMLStreamException {
        Attribute attribute = element.getAttributeByName(name);

        if (attribute == null) {
            if (def == null)
                throw new XMLStreamException(onErrorMessage, element.getLocation());

            return def;
        }

        return attribute.getValue();
    }

    /**
     * Read all {@link javax.xml.stream.events.XMLEvent}'s from specified file and write them onto the
     * {@link javax.xml.stream.XMLEventWriter}
     *
     * @param file     File to import
     * @param skipRoot Skip-root flag
     * @param writer   Destenation writer
     * @throws XMLStreamException    On event reading/writing error.
     * @throws FileNotFoundException if the reading file does not exist, is a directory rather than a regular file, or for some other reason
     *                               cannot be opened for reading.
     */
    private void importFile(File file, boolean skipRoot, XMLEventWriter writer, Properties metadata)
            throws XMLStreamException, IOException {
        logger.debug("Appending file " + file);
        metadata.setProperty(file.getPath(), makeHash(file));

        XMLEventReader reader = null;

        try {
            reader = inputFactory.createXMLEventReader(new FileReader(file));

            QName firstTagQName = null;

            while (reader.hasNext()) {
                XMLEvent event = reader.nextEvent();

                // skip start and end of document.
                if (event.isStartDocument() || event.isEndDocument())
                    continue;
                // skip all comments.
                if (event instanceof Comment)
                    continue;
                // skip white-spaces and all ignoreable white-spaces.
                if (event.isCharacters()) {
                    if (event.asCharacters().isWhiteSpace() || event.asCharacters().isIgnorableWhiteSpace())
                        continue;
                }

                // modify root-tag of imported file.
                if (firstTagQName == null && event.isStartElement()) {
                    firstTagQName = event.asStartElement().getName();

                    if (skipRoot) {
                        continue;
                    } else {
                        StartElement old = event.asStartElement();

                        event = eventFactory.createStartElement(old.getName(), old.getAttributes(), null);
                    }
                }

                // if root was skipped - skip root end too.
                if (event.isEndElement() && skipRoot && event.asEndElement().getName().equals(firstTagQName))
                    continue;

                // finally - write tag
                writer.add(event);
            }
        } finally {
            if (reader != null)
                try {
                    reader.close();
                } catch (Exception ignored) {
                }
        }
    }

    private static class TimeCheckerHandler extends DefaultHandler {

        private File basedir;
        private Properties metadata;

        private boolean isModified = false;

        private Locator locator;

        private TimeCheckerHandler(File basedir, Properties metadata) {
            this.basedir = basedir;
            this.metadata = metadata;
        }

        @Override
        public void setDocumentLocator(Locator locator) {
            this.locator = locator;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
            if (isModified || !"import".equals(qName))
                return;

            String value = attributes.getValue(qNameFile.getLocalPart());

            if (value == null)
                throw new SAXParseException("Attribute 'file' is missing", locator);

            File file = new File(basedir, value);

            if (!file.exists())
                // noinspection ThrowableInstanceNeverThrown
                throw new SAXParseException("Imported file not found. file=" + file.getPath(), locator);

            if (file.isFile() && checkFile(file))// if file - just check it.
            {
                isModified = true;
                return;
            }

            if (file.isDirectory())// otherwise check all files inside
            {
                String rec = attributes.getValue(qNameRecursiveImport.getLocalPart());

                Collection<File> files = listFiles(file, rec == null ? true : Boolean.valueOf(rec));

                for (File childFile : files) {
                    if (checkFile(childFile)) {
                        isModified = true;
                        return;
                    }
                }
            }
        }

        private boolean checkFile(File file) {
            String data = metadata.getProperty(file.getPath());

            if (data == null) // file was added.
                return true;

            try {
                String hash = makeHash(file);

                if (!data.equals(hash))// file|dir was changed.
                    return true;
            } catch (IOException e) {
                logger.warn("File varification error. File: " + file.getPath() + ", location=" + locator.getLineNumber() + ":"
                        + locator.getColumnNumber(), e);
                return true;// was modified.
            }

            return false;
        }

        public boolean isModified() {
            return isModified;
        }
    }

    private Properties restoreFileModifications(File file) {
        if (!file.exists() || !file.isFile())
            return null;

        FileReader reader = null;

        try {
            Properties props = new Properties();

            reader = new FileReader(file);

            props.load(reader);

            return props;
        } catch (IOException e)// properties
        {
            logger.debug("File modfications restoring error. ", e);
            return null;
        } finally {
            IOUtils.closeQuietly(reader);
        }
    }

    private void storeFileModifications(Properties props, File file) throws IOException {
        FileWriter writer = null;
        try {
            writer = new FileWriter(file, false);
            props.store(writer, " This file is machine-generated. DO NOT EDIT!");
        } catch (IOException e) {
            logger.error("Failed to store file modification data.");
            throw e;
        } finally {
            IOUtils.closeQuietly(writer);
        }
    }

    /**
     * Create a unique identifier of file and it contents.
     *
     * @param file the file to checksum, must not be <code>null</code>
     * @return String identifier
     * @throws IOException if an IO error occurs reading the file
     */
    private static String makeHash(File file) throws IOException {
        return String.valueOf(FileUtils.checksumCRC32(file));
    }
}
